Speed up your Rack application with HTTP

If you’re like me, then you probably take application caching and performance improvements for granted because you rely on a web framework like Rails to do this for you. Although this level of abstraction is helpful, it can also obscure the underlying mechanisms, making it challenging to diagnose issues or further optimize your application.

In this tutorial, we’ll learn how to leverage HTTP to improve the performance of a simple Rack application in an effort to demystify the intricacies of caching strategies and performance enhancement at the foundational level. If you want to learn more about the Rack application, feel free to read our pragmatic guide to building a Rack application from scratch.

Below is a simplified version of our application’s config.ru file from which we will be working with. If at any time you wish to explore on your own, feel free to review the commit history.

require_relative "app/app"

app = Rack::Builder.new do
  use Rack::Static,
    root: "public",
    urls: ["/css"],
  run App.new
end

run app

Caching

Since our application’s style sheet is not likely to frequently change, it’s the perfect candidate for HTTP Caching. This is even confirmed by a Lighthouse Performance Audit which suggests we serve static assets with an effective caching strategy.

In image of a Lighthouse Performance violation. It says our styles.css file is
not
cached.

Since we’re using Rack::Static, we can easily resolve this by adding custom header rules for all static files served through our application.

 require_relative "app/app"

 app = Rack::Builder.new do
   use Rack::Static,
     root: "public",
     urls: ["/css"],
+    header_rules: [
+      [:all, {"Cache-Control" => "public, max-age=31556952"}]
+    ]
   run App.new
 end

What this says is that we want to cache our style sheet for 1 year (31556952= the number of seconds in a year). We also set this cache to public since the style sheet does not contain any user-specific information. This is important because by using public, we’re permitting the response to be cached by shared caches, like a Content Delivery Network (CDN) or a proxy.

If we restart our server and run another performance audit, we’ll see that the violation has been resolved.

In image of a Lighthouse Performance audit. It says we have no 'Uses efficient
cache policy on static assets'
violations.

Conditional Requests

You might be thinking that we should set the Cache-Control header on server rendered pages too. Although we could do that, a more effective approach is to use HTTP conditional requests. This ensures that the browser’s cache can be used if the response body hasn’t changed since the last request.

This works by setting an ETag to identify the version of a specific resource (usually by hashing the response body) and comparing it to the If-None-Match header in the request. If the two values match, then a 304 Not Modified is returned instead of a 200 OK.

We could do this manually, but fortunately for us, the Rack library ships with Rack::ConditionalGet to do this for us. All we need to do is add it to our stack.

 require_relative "app/app"

 app = Rack::Builder.new do
+  use Rack::ConditionalGet
+  use Rack::ETag
   use Rack::Deflater
   use Rack::Static
     root: "public",
     urls: ["/css"],

We can verify that this worked by visiting a server-rendered page in our application. The first request should result in a 200 OK, but any subsequent request will return a 304 Not Modified (so long as the response body hasn’t changed), resulting in a smaller response size.

Before An image of a network request in the developer tools. The status is a 200, and
the size is
1.2kB.

After An image of the same network request in the developer tools. This time, the
status is a 304, and the size is only
396B.

It’s worth noting that even though the response size was reduced, the response time remained the same. This is because the server-side logic used to build the response body was still invoked in order to compare the headers. If we wanted to further improve performance, we could store the response in a cache store like Redis.

if (requested_etag = req.headers["If-None-Match"]) && etag_still_warm_in_redis_cache?(requested_etag)
  [304, {}, []]
else
  slow_uncached_action
end

We can also verify that the ETag and If-None-Match headers are set by inspecting the request.

An image of the Response and Request headers viewed from the developer tools.
Both the ETag and If-None-Match headers are set to
W/"26647a614ce7c20db0b774e5b60e089c"

Compression

Finally, we can leverage HTTP Compression to drastically reduce the size of response documents in an effort to improve performance. We can confirm our application is not utilizing any sort of compression by running a Lighthouse Performance Audit.

In image of a Lighthouse Performance violation. It says Text-based resources
should be served with compression (gzip, deflate or brotli) to minimize total
network
bytes.

Luckily, the Rack library makes implementing this fix effortless with the Rack::Deflator middleware (which we’ve written about before). All we need to do is add it to our stack.

 require_relative "app/app"

 app = Rack::Builder.new do
+  use Rack::Deflater
   use Rack::ConditionalGet
   use Rack::ETag
   use Rack::Static,
     root: "public",
     urls: ["/css"],

We can verify that our application is using HTTP Compression by running the performance audit again.

In image of the previous Lighthouse Performance violation. This time it says
text compression is
enabled.

Wrapping up

The concepts we’ve discussed aren’t specific to Rack and serve as a reminder that understanding HTTP is vital to web application development.

Rack makes setting these headers easy thanks to its available middleware, but there’s nothing stopping you from manually setting a response header in your Rack compliant application.